SLOPSHOPPER

thermal

KOZMOS Thermal: a heatmap of the files this session touches. Reads, edits, writes and searches per file, a heat score that cools over time, grouped as a…

newpaneguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/thermal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thermal
│ ┃ KOZMOS · Thermal ✕ › fix the failing auth test and add an audit log call │ ┃ ▓▒░ THERMAL 4 paths · 5 touches [ ◐ heat → r │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▾ src/ ■■■■■ r1 s1 e1 w2now ⎿ Read 6 lines │ ┃ • cache.ts ■■■■■ w1 now ⏺ Update(src/auth.ts) │ ┃ • audit.ts ■■■■■ w1 now ⎿ Added 2 lines, removed 1 line │ ┃ • auth.ts ■■■■■ r1 e1 now ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ cold ■■■■■ hot · r read s search e edit w w… │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /thermal │ ⎿ thermal: Thermal open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Thermal
▓▒░ THERMAL 4 paths · 5 touches [ ◐ heat → reads ] ▾ src/ ■■■■■ r1 s1 e1 w2now • cache.ts ■■■■■ w1 now • audit.ts ■■■■■ w1 now • auth.ts ■■■■■ r1 e1 now cold ■■■■■ hot · r read s search e edit w write
README

KOZMOS Thermal

A heatmap of the files this session touches, in a sidebar pane. /thermal toggles it.

Every Read, Edit, Write, NotebookEdit (by file_path) and every Grep / Glob with a path is counted, in the main loop and in agents. Each file keeps its reads, searches, edits, writes, last touch, and a heat score: read 1, search 0.5, edit 3, write 4, halving every 10 minutes. Paths are grouped as a directory tree under the session folder (files elsewhere sit under ↗ outside); folders add up their children, and chains of single folders fold into one row (src/lib).

  • Terminal: an indented tree, hottest first at every level, with five colored heat blocks ■■■□□, the counts (r read, s search, e edit, w write) and the age of the last touch; as many rows as the pane holds.
  • Desktop: a squarified treemap (folders nest with their name on a header strip), tiles sized by the metric and colored on a cold-indigo to hot-yellow ramp, labels where they fit, a tooltip per tile, the hottest file outlined.
  • Metric button (m): cycles heat → reads → edits.

Settings: autoOpen (default false) opens the pane when a session starts.

Türkçe

Oturumun dokunduğu dosyaların ısı haritası, bir kenar panelinde. /thermal açıp kapatır.

Her Read, Edit, Write, NotebookEdit (file_path ile) ve path verilmiş her Grep / Glob sayılır; ana döngüde de ajanlarda da. Her dosya okuma, arama, düzenleme, yazma sayılarını, son dokunuşu ve bir ısı puanını tutar: okuma 1, arama 0.5, düzenleme 3, yazma 4; ısı her 10 dakikada yarıya iner. Yollar oturum klasörü altında bir dizin ağacı olarak gruplanır (dışarıdaki dosyalar ↗ outside altında); klasörler çocuklarını toplar, tek klasörlük zincirler tek satıra katlanır (src/lib).

  • Terminal: her seviyede en sıcak önde, girintili bir ağaç; beş renkli ısı bloğu ■■■□□, sayılar (r okuma, s arama, e düzenleme, w yazma) ve son dokunuşun yaşı; panelin sığdırdığı kadar satır.
  • Masaüstü: kare oranlı bir ağaç haritası (treemap; klasörler başlık şeridinde adlarıyla iç içe), karolar metriğe göre boyutlanır ve soğuk çividen sıcak sarıya giden bir renk skalasıyla boyanır, sığdığı yerde etiket, her karoda ipucu, en sıcak dosyanın çevresi vurgulu.
  • Metrik düğmesi (m): ısı → okuma → düzenleme arasında döner.

Ayar: autoOpen (varsayılan false) oturum başlarken paneli açar.

Source 4 files
hooks/register.tsx 325 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ThermalFile, ThermalMetric, ThermalSnap } from '../types'
5import { KZ, fitText, fmtSpan, pxOf, svg, svgText, xml } from './lib/kz.ts'
6import { HALF_LIFE_MS, buildTree, flatten, hit, metricOf, ranked, squarify, thermal, treePath } from './heatmap.ts'
7import type { HitKind, Rect, Stats, TreeNode } from './heatmap.ts'
8
9const PANE = 'kz-thermal'
10const TITLE = 'KOZMOS · Thermal'
11const MAX_FILES = 500
12const snapAtom = atom({ plugin: 'thermal', key: 'snap' } as const, null)
13const metricAtom = atom({ plugin: 'thermal', key: 'metric' } as const, 'heat')
14const NEXT: Record<ThermalMetric, ThermalMetric> = { heat: 'reads', reads: 'edits', edits: 'heat' }
15const METRIC_LABEL: Record<ThermalMetric, string> = { heat: 'heat', reads: 'reads', edits: 'edits' }
16
17let files = new Map<string, ThermalFile>()
18let cwd = ''
19let isDirty = true
20let lastPubAt = -Infinity
21
22async function isOpen($: EngineInterface): Promise<boolean> {
23  return (await $.ui.panes()).some(p => p.id === PANE)
24}
25
26async function toggle($: EngineInterface): Promise<boolean> {
27  if (await isOpen($)) {
28    await $.ui.close({ id: PANE })
29    return false
30  }
31  await $.ui.open({ id: PANE, title: TITLE })
32  await publish($)
33  return true
34}
35
36async function publish($: EngineInterface): Promise<void> {
37  const now = await $.clock.now()
38  isDirty = false
39  lastPubAt = now
40  const snap: ThermalSnap = { cwd, files: [...files.values()].map(f => ({ ...f })), now }
41  await update($, snapAtom, () => snap)
42}
43
44/** Redraws for new touches at once, and every 15 s while open so heat visibly cools. */
45async function tick($: EngineInterface): Promise<void> {
46  if (!(await isOpen($))) return
47  const now = await $.clock.now()
48  if (isDirty || (files.size > 0 && now - lastPubAt >= 15_000)) await publish($)
49}
50
51async function record($: EngineInterface, tool: string, input: Record<string, unknown>): Promise<void> {
52  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
53  let kind: HitKind | undefined
54  let target = ''
55  if (tool === 'Read') {
56    kind = 'read'
57    target = str('file_path')
58  } else if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
59    kind = 'edit'
60    target = str('file_path') || str('notebook_path')
61  } else if (tool === 'Write') {
62    kind = 'write'
63    target = str('file_path')
64  } else if (tool === 'Grep' || tool === 'Glob') {
65    kind = 'search'
66    target = str('path')
67  }
68  if (!kind || !target) return
69  if (!cwd) cwd = await $.session.cwd()
70  const now = await $.clock.now()
71  const key = treePath(cwd, target)
72  files.set(key, hit(files.get(key), key, kind, now))
73  if (files.size > MAX_FILES) {
74    // Drop the coldest (there are more than MAX_FILES to pick from).
75    const coldest = [...files.values()].sort((a, b) => a.heat * Math.pow(0.5, (now - a.heatAt) / HALF_LIFE_MS) - b.heat * Math.pow(0.5, (now - b.heatAt) / HALF_LIFE_MS))[0]!
76    files.delete(coldest.path)
77  }
78  isDirty = true
79  if (await isOpen($)) await publish($)
80}
81
82// ---------------------------------------------------------------------------
83
84export const register: Register = (on, options) => {
85  on('session.start', async ($, e, next) => {
86    const started = await next(e)
87    files = new Map()
88    cwd = ''
89    isDirty = true
90    lastPubAt = -Infinity
91    try {
92      cwd = await $.session.cwd()
93    } catch {
94      cwd = ''
95    }
96    await $.command.register({ name: 'thermal', description: 'KOZMOS: toggle the Thermal heatmap of files this session touched', immediate: true })
97    $.clock.every(5000, () => void tick($).catch(() => undefined))
98    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
99    return started
100  })
101
102  on('command.run', { command: 'thermal' }, async $ => ({
103    text: (await toggle($)) ? 'Thermal open.' : 'Thermal closed.',
104  }))
105
106  on('tool.call', async ($, e, next) => {
107    const ran = await next(e)
108    if (ran.deny === undefined) await record($, String(e.tool), e as unknown as Record<string, unknown>).catch(() => undefined)
109    return ran
110  }).catch(($, e, next) => next(e))
111
112  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113    const snap = await read($, snapAtom)
114    const metric = await read($, metricAtom)
115    const ui = $.ui.resolve(e)
116    const { Box, Text, Button } = ui
117    const cols = Math.max(28, e.props.bodyColumns || 40)
118    const now = snap?.now ?? (await $.clock.now())
119    const root = buildTree(snap?.files ?? [], now)
120    const touches = (snap?.files ?? []).reduce((n, f) => n + f.reads + f.edits + f.writes + f.searches, 0)
121    const fileCount = snap?.files.length ?? 0
122
123    const switcher = (
124      <Button key="metric" hotkey="m" dimColor onPress={() => void update($, metricAtom, m => NEXT[m])}>
125        {`◐ ${METRIC_LABEL[metric]} → ${METRIC_LABEL[NEXT[metric]]}`}
126      </Button>
127    )
128
129    if ('Svg' in ui && e.surface !== 'terminal') {
130      const { Svg } = ui
131      const W = pxOf(cols, 44)
132      const { source, height } = treemapCard(root, metric, W, fileCount, touches)
133      return (
134        <Box flexDirection="column">
135          {switcher}
136          <Svg source={source} alt={altText(root, metric, fileCount)} width={W} height={height} isInteractive />
137        </Box>
138      )
139    }
140
141    const head = (
142      <Box flexDirection="row" justifyContent="space-between">
143        <Text wrap="truncate-end">
144          <Text bold color={KZ.amber}>▓▒░ THERMAL</Text>
145          <Text dimColor> {fileCount} paths · {touches} touches</Text>
146        </Text>
147        <Box flexShrink={0} marginLeft={1}>{switcher}</Box>
148      </Box>
149    )
150
151    if (fileCount === 0) {
152      return (
153        <Box flexDirection="column">
154          {head}
155          <Box marginTop={1} flexDirection="column" alignItems="center">
156            <Text color={thermal(0.1)}>░░▒▒▓▓██▓▓▒▒░░</Text>
157            <Text dimColor>No files touched yet.</Text>
158            <Text dimColor wrap="wrap">Reads, edits and searches warm up here.</Text>
159          </Box>
160        </Box>
161      )
162    }
163
164    const limit = Math.max(4, (e.props.scroll.bodyRows || 30) - 4)
165    const { rows, hidden } = flatten(root, metric, limit)
166    const maxDir = Math.max(1e-9, ...rows.filter(r => r.isDir).map(r => metricOf(r.node.stats, metric)))
167    const maxFile = Math.max(1e-9, ...rows.filter(r => !r.isDir).map(r => metricOf(r.node.stats, metric)))
168    const blocks = (ratio: number) => {
169      // Rows come from `ranked`, so every value (and ratio) is above zero.
170      const n = Math.max(1, Math.ceil(ratio * 5))
171      return Array.from({ length: 5 }, (_, i) => (
172        <Text key={`b${i}`} color={i < n ? thermal((i + 1) / 5) : KZ.mist} dimColor={i >= n}>{i < n ? '■' : '□'}</Text>
173      ))
174    }
175    const counts = (s: Stats) => [
176      s.reads ? `r${s.reads}` : '', s.searches ? `s${s.searches}` : '', s.edits ? `e${s.edits}` : '', s.writes ? `w${s.writes}` : '',
177    ].filter(Boolean).join(' ')
178    const countW = Math.min(14, Math.max(...rows.map(r => counts(r.node.stats).length), 2))
179    const showAge = cols >= 44
180    const maxHeat = Math.max(1e-9, maxHeatOf(root))
181
182    return (
183      <Box flexDirection="column">
184        {head}
185        <Box marginTop={1} flexDirection="column">
186          {rows.map(r => {
187            const v = metricOf(r.node.stats, metric)
188            const ratio = v / (r.isDir ? maxDir : maxFile)
189            const hot = thermal(Math.min(1, r.node.stats.heat / maxHeat))
190            return (
191              <Box key={r.node.path} flexDirection="row">
192                <Box flexGrow={1} flexShrink={1}>
193                  <Text wrap="truncate-end">
194                    {'  '.repeat(r.depth)}
195                    <Text color={r.isDir ? KZ.mist : hot}>{r.isDir ? '▾ ' : '• '}</Text>
196                    <Text bold={r.isDir} color={r.isDir ? KZ.blue : undefined}>{r.node.name}{r.isDir ? '/' : ''}</Text>
197                  </Text>
198                </Box>
199                <Box flexShrink={0} marginLeft={1}><Text>{blocks(ratio)}</Text></Box>
200                <Box flexShrink={0} width={countW + 1} marginLeft={1}><Text dimColor wrap="truncate-end">{counts(r.node.stats)}</Text></Box>
201                {showAge ? <Box flexShrink={0} width={5}><Text dimColor>{fmtAge(now - r.node.stats.lastAt)}</Text></Box> : null}
202              </Box>
203            )
204          })}
205        </Box>
206        {hidden > 0 ? <Text dimColor>  … {hidden} more</Text> : null}
207        <Box marginTop={1}>
208          <Text wrap="truncate-end" dimColor>
209            cold <Text color={thermal(0)}>■</Text><Text color={thermal(0.3)}>■</Text><Text color={thermal(0.55)}>■</Text><Text color={thermal(0.8)}>■</Text><Text color={thermal(1)}>■</Text> hot · r read s search e edit w write
210          </Text>
211        </Box>
212      </Box>
213    )
214  })
215}
216
217function fmtAge(ms: number): string {
218  return ms < 30_000 ? 'now' : fmtSpan(ms)
219}
220
221/** The hottest single file's heat (folders aggregate, so they are left out). */
222function maxHeatOf(root: TreeNode): number {
223  let m = 0
224  const walk = (n: TreeNode) => {
225    if (n.hasSelf || n.children.length === 0) {
226      const own = n.children.length ? n.stats.heat - n.children.reduce((a, c) => a + c.stats.heat, 0) : n.stats.heat
227      m = Math.max(m, own)
228    }
229    n.children.forEach(walk)
230  }
231  walk(root)
232  return m
233}
234
235function altText(root: TreeNode, m: ThermalMetric, count: number): string {
236  if (!count) return 'No files touched yet'
237  const top = flatten(root, m, 400).rows.filter(r => !r.isDir).slice(0, 5).map(r => `${r.node.path} (${Math.round(metricOf(r.node.stats, m) * 10) / 10})`)
238  return `Treemap of ${count} files by ${m}; hottest: ${top.join(', ')}`
239}
240
241// ---------------------------------------------------------------------------
242// Desktop: a squarified treemap colored by heat.
243
244const CSS = `
245.tile{transition:opacity .2s}.tile:hover{opacity:.82}
246.glow{animation:thglow 2.2s ease-in-out infinite}@keyframes thglow{50%{opacity:.15}}
247`
248
249function treemapCard(root: TreeNode, m: ThermalMetric, W: number, count: number, touches: number): { source: string; height: number } {
250  const parts: string[] = []
251  const pad = 12
252  const headH = 46
253  // Header: title, totals, a legend ramp.
254  parts.push(`<rect class="p" x="0" y="0" width="${W}" height="${headH}" rx="12"/>`)
255  parts.push(svgText(pad, 20, 'THERMAL', { size: 12, weight: 750, fill: KZ.amber }))
256  parts.push(svgText(pad, 36, `${count} paths · ${touches} touches · sized by ${m}`, { cls: 'm', size: 10.5 }))
257  const lw = Math.min(120, W * 0.3)
258  const lx = W - pad - lw
259  parts.push(`<defs><linearGradient id="thRamp" x1="0" x2="1">${[0, 0.3, 0.55, 0.8, 1].map(t => `<stop offset="${t}" stop-color="${thermal(t)}"/>`).join('')}</linearGradient></defs>`)
260  parts.push(`<rect x="${lx}" y="16" width="${lw}" height="8" rx="4" fill="url(#thRamp)"/>`)
261  parts.push(svgText(lx, 38, 'cold', { cls: 'm', size: 9.5 }))
262  parts.push(svgText(lx + lw, 38, 'hot', { cls: 'm', size: 9.5, anchor: 'end' }))
263
264  const mapY = headH + 8
265  if (count === 0 || ranked(root, m).length === 0) {
266    const H = mapY + 120
267    parts.push(`<rect class="p" x="0" y="${mapY}" width="${W}" height="112" rx="12"/>`)
268    parts.push(`<rect x="${W / 2 - 40}" y="${mapY + 24}" width="80" height="24" rx="6" fill="url(#thRamp)" opacity=".35" class="glow"/>`)
269    parts.push(svgText(W / 2, mapY + 72, count === 0 ? 'No files touched yet' : `Nothing to size by ${m} yet`, { size: 13, weight: 650, anchor: 'middle' }))
270    parts.push(svgText(W / 2, mapY + 92, 'Reads, edits and searches warm up here.', { cls: 'm', size: 10.5, anchor: 'middle' }))
271    return { source: svg(W, H, parts.join(''), CSS), height: H }
272  }
273
274  const mapH = Math.round(Math.max(220, Math.min(560, W * 0.78)))
275  const maxHeat = Math.max(1e-9, maxHeatOf(root))
276  let hottest: { rect: Rect; heat: number } | undefined
277
278  const tile = (n: TreeNode, r: Rect, isFolderLeaf: boolean) => {
279    const t = Math.min(1, n.stats.heat / maxHeat)
280    const fill = thermal(t)
281    const ink = t > 0.72 ? '#1f1e1d' : '#ffffff'
282    const x = r.x + 1
283    const y = r.y + 1
284    const w = Math.max(0, r.w - 2)
285    const h = Math.max(0, r.h - 2)
286    const s = n.stats
287    const tip = `${n.path}${isFolderLeaf ? '/' : ''} · heat ${s.heat.toFixed(1)} · ${s.reads} reads · ${s.searches} searches · ${s.edits} edits · ${s.writes} writes`
288    parts.push(`<g class="tile"><title>${xml(tip)}</title><rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${Math.min(5, w / 4, h / 4).toFixed(1)}" fill="${fill}"/>`)
289    if (w > 34 && h > 16) {
290      const size = w > 120 && h > 44 ? 12 : 10.5
291      parts.push(svgText(x + 5, y + size + 3, fitText(isFolderLeaf ? `${n.name}/` : n.name, size, w - 10), { size, weight: 650, fill: ink }))
292      if (h > 34 && w > 50) {
293        const line = [s.reads ? `r${s.reads}` : '', s.searches ? `s${s.searches}` : '', s.edits ? `e${s.edits}` : '', s.writes ? `w${s.writes}` : ''].filter(Boolean).join(' ')
294        parts.push(`<text x="${(x + 5).toFixed(1)}" y="${(y + size + 17).toFixed(1)}" fill="${ink}" fill-opacity=".75" font-size="9.5" font-family="ui-monospace,Consolas,monospace">${xml(fitText(line, 9.5, w - 10))}</text>`)
295      }
296    }
297    parts.push('</g>')
298    if (!hottest || n.stats.heat > hottest.heat) hottest = { rect: { x, y, w, h }, heat: n.stats.heat }
299  }
300
301  const draw = (n: TreeNode, r: Rect, depth: number) => {
302    const items = ranked(n, m).map(c => ({ value: metricOf(c.stats, m), item: c }))
303    for (const p of squarify(items, r)) {
304      const c = p.item
305      const canNest = c.children.length > 0 && p.w > 56 && p.h > 40 && depth < 5
306      if (!canNest) {
307        tile(c, p, c.children.length > 0)
308        continue
309      }
310      const t = Math.min(1, c.stats.heat / (maxHeat * 2))
311      parts.push(`<rect x="${(p.x + 1).toFixed(1)}" y="${(p.y + 1).toFixed(1)}" width="${(p.w - 2).toFixed(1)}" height="${(p.h - 2).toFixed(1)}" rx="6" fill="${thermal(t)}" fill-opacity=".16" stroke="${thermal(t)}" stroke-opacity=".45" stroke-width="1"/>`)
312      parts.push(svgText(p.x + 6, p.y + 13, fitText(`${c.name}/`, 10, p.w - 12), { cls: 's', size: 10, weight: 700 }))
313      draw(c, { x: p.x + 3, y: p.y + 17, w: p.w - 6, h: p.h - 20 }, depth + 1)
314    }
315  }
316  parts.push(`<rect class="p" x="0" y="${mapY}" width="${W}" height="${mapH}" rx="12"/>`)
317  draw(root, { x: 4, y: mapY + 4, w: W - 8, h: mapH - 8 }, 0)
318  const hot = hottest as { rect: Rect; heat: number } | undefined
319  if (hot && hot.rect.w > 6 && hot.rect.h > 6) {
320    parts.push(`<rect class="glow" x="${hot.rect.x.toFixed(1)}" y="${hot.rect.y.toFixed(1)}" width="${hot.rect.w.toFixed(1)}" height="${hot.rect.h.toFixed(1)}" rx="5" fill="none" stroke="${KZ.yellow}" stroke-width="2" pointer-events="none"/>`)
321  }
322  const H = mapY + mapH
323  return { source: svg(W, H, parts.join(''), CSS), height: H }
324}
325
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/heatmap.ts 232 lines
1// thermal's pure core: heat with decay, the directory tree, its flattening
2// for the terminal, and a squarified treemap for the desktop. No `$` here.
3
4import type { ThermalFile, ThermalMetric } from '../types'
5import { KZ, mix } from './lib/kz.ts'
6
7export const HALF_LIFE_MS = 10 * 60_000
8export const WEIGHTS = { read: 1, search: 0.5, edit: 3, write: 4 } as const
9export type HitKind = keyof typeof WEIGHTS
10
11export function decayed(heat: number, at: number, now: number): number {
12  return heat * Math.pow(0.5, Math.max(0, now - at) / HALF_LIFE_MS)
13}
14
15/** Records one touch on a file (a fresh record when absent). */
16export function hit(prev: ThermalFile | undefined, path: string, kind: HitKind, now: number): ThermalFile {
17  const f: ThermalFile = prev ? { ...prev } : { path, reads: 0, edits: 0, writes: 0, searches: 0, heat: 0, heatAt: now, lastAt: now }
18  if (kind === 'read') f.reads++
19  else if (kind === 'edit') f.edits++
20  else if (kind === 'write') f.writes++
21  else f.searches++
22  f.heat = decayed(f.heat, f.heatAt, now) + WEIGHTS[kind]
23  f.heatAt = now
24  f.lastAt = now
25  return f
26}
27
28// ---------------------------------------------------------------------------
29// Paths.
30
31function norm(p: string): string {
32  let s = p.trim().replace(/\\/g, '/')
33  const msys = /^\/([a-zA-Z])\//.exec(s)
34  if (msys) s = `${msys[1]}:/${s.slice(3)}`
35  return s.replace(/\/+$/, '')
36}
37
38const isAbs = (p: string) => /^([a-zA-Z]:)?\//.test(p)
39const isWinPath = (p: string) => /^[a-zA-Z]:\//.test(p)
40
41export const OUTSIDE = '↗ outside'
42
43/** The tree path of `file` as seen from `cwd`: relative inside it, grouped under OUTSIDE when not. */
44export function treePath(cwd: string, file: string): string {
45  const c = norm(cwd)
46  let f = norm(file)
47  if (!isAbs(f)) f = `${c}/${f.replace(/^\.\//, '')}`
48  const fold = isWinPath(c) ? (s: string) => s.toLowerCase() : (s: string) => s
49  if (fold(f) === fold(c)) return '.'
50  if (c && fold(f).startsWith(fold(c) + '/')) {
51    // Resolve ./ and ../ inside the cwd.
52    const out: string[] = []
53    for (const seg of f.slice(c.length + 1).split('/')) {
54      if (seg === '' || seg === '.') continue
55      if (seg === '..') out.pop()
56      else out.push(seg)
57    }
58    return out.join('/') || '.'
59  }
60  const i = f.lastIndexOf('/')
61  return `${OUTSIDE}/${i > 0 ? f.slice(0, i) : '/'}/${f.slice(i + 1)}`.replace(/\/\/+/g, '/')
62}
63
64// ---------------------------------------------------------------------------
65// The tree.
66
67export type Stats = { reads: number; edits: number; writes: number; searches: number; heat: number; lastAt: number }
68export type TreeNode = { name: string; path: string; children: TreeNode[]; stats: Stats; hasSelf: boolean }
69
70const zero = (): Stats => ({ reads: 0, edits: 0, writes: 0, searches: 0, heat: 0, lastAt: 0 })
71
72function add(a: Stats, b: Stats): void {
73  a.reads += b.reads
74  a.edits += b.edits
75  a.writes += b.writes
76  a.searches += b.searches
77  a.heat += b.heat
78  a.lastAt = Math.max(a.lastAt, b.lastAt)
79}
80
81export function metricOf(s: Stats, m: ThermalMetric): number {
82  if (m === 'reads') return s.reads + s.searches
83  if (m === 'edits') return s.edits + s.writes
84  return s.heat
85}
86
87/**
88 * Builds the directory tree: folders aggregate their children, a path that
89 * was touched itself and has children keeps its own share too; chains of
90 * single folders fold into one node (`src/lib`), as an editor's tree does.
91 */
92export function buildTree(files: readonly ThermalFile[], now: number): TreeNode {
93  type Raw = { name: string; kids: Map<string, Raw>; self?: Stats }
94  const root: Raw = { name: '.', kids: new Map() }
95  for (const f of files) {
96    let at = root
97    if (f.path !== '.') {
98      for (const seg of f.path.split('/')) {
99        let next = at.kids.get(seg)
100        if (!next) {
101          next = { name: seg, kids: new Map() }
102          at.kids.set(seg, next)
103        }
104        at = next
105      }
106    }
107    const s: Stats = { reads: f.reads, edits: f.edits, writes: f.writes, searches: f.searches, heat: decayed(f.heat, f.heatAt, now), lastAt: f.lastAt }
108    if (at.self) add(at.self, s)
109    else at.self = s
110  }
111  const make = (r: Raw, path: string): TreeNode => {
112    const children = [...r.kids.values()].map(k => make(k, path ? `${path}/${k.name}` : k.name))
113    const stats = zero()
114    if (r.self) add(stats, r.self)
115    for (const c of children) add(stats, c.stats)
116    let node: TreeNode = { name: r.name, path, children, stats, hasSelf: r.self !== undefined }
117    // Fold a folder holding exactly one folder and nothing of its own.
118    while (!node.hasSelf && node.children.length === 1 && node.children[0]!.children.length > 0 && node.path !== '') {
119      const only = node.children[0]!
120      node = { ...only, name: `${node.name}/${only.name}` }
121    }
122    return node
123  }
124  return make(root, '')
125}
126
127/** Children sorted hottest first by the metric, zero-valued ones dropped. */
128export function ranked(node: TreeNode, m: ThermalMetric): TreeNode[] {
129  return node.children.filter(c => metricOf(c.stats, m) > 0).sort((a, b) => metricOf(b.stats, m) - metricOf(a.stats, m) || a.name.localeCompare(b.name))
130}
131
132export type Row = { node: TreeNode; depth: number; isDir: boolean }
133
134/** Depth-first rows, hottest first at every level, at most `limit` of them. */
135export function flatten(root: TreeNode, m: ThermalMetric, limit: number): { rows: Row[]; hidden: number } {
136  const rows: Row[] = []
137  let total = 0
138  const walk = (n: TreeNode, depth: number) => {
139    for (const c of ranked(n, m)) {
140      total++
141      if (rows.length < limit) rows.push({ node: c, depth, isDir: c.children.length > 0 })
142      walk(c, depth + 1)
143    }
144  }
145  walk(root, 0)
146  return { rows, hidden: Math.max(0, total - rows.length) }
147}
148
149// ---------------------------------------------------------------------------
150// Color: a thermal ramp, cold indigo through violet and magenta to amber and yellow.
151
152const RAMP: [number, string][] = [[0, '#3b4b9a'], [0.3, KZ.violet], [0.55, KZ.magenta], [0.8, KZ.amber], [1, KZ.yellow]]
153
154export function thermal(t: number): string {
155  const k = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0))
156  // The first stop at or past k (never the 0 stop): the last stop is 1, so one always is.
157  const i = Math.max(1, RAMP.findIndex(([p]) => k <= p))
158  const [p1, c1] = RAMP[i]!
159  const [p0, c0] = RAMP[i - 1]!
160  return mix(c0, c1, (k - p0) / (p1 - p0))
161}
162
163// ---------------------------------------------------------------------------
164// Squarified treemap (Bruls, Huizing, van Wijk): lay rows of rectangles along
165// the shorter side, adding an item to the row while that improves the row's
166// worst aspect ratio, else freezing the row and starting the next.
167
168export type Rect = { x: number; y: number; w: number; h: number }
169export type Placed<T> = Rect & { item: T }
170
171function worst(areas: readonly number[], side: number): number {
172  if (!areas.length || side <= 0) return Infinity
173  const s = areas.reduce((a, b) => a + b, 0)
174  const hi = Math.max(...areas)
175  const lo = Math.min(...areas)
176  const s2 = s * s
177  const w2 = side * side
178  return Math.max((w2 * hi) / s2, s2 / (w2 * lo))
179}
180
181export function squarify<T>(items: readonly { value: number; item: T }[], rect: Rect): Placed<T>[] {
182  const list = items.filter(i => i.value > 0).sort((a, b) => b.value - a.value)
183  const total = list.reduce((a, b) => a + b.value, 0)
184  if (!list.length || rect.w <= 0 || rect.h <= 0 || total <= 0) return []
185  const scale = (rect.w * rect.h) / total
186  const areas = list.map(i => i.value * scale)
187  const out: Placed<T>[] = []
188  let r = { ...rect }
189  let row: number[] = []
190  let rowStart = 0
191
192  const layRow = (from: number, rowAreas: number[]) => {
193    const s = rowAreas.reduce((a, b) => a + b, 0)
194    if (r.w >= r.h) {
195      // A column at the left, as wide as the row's area over the height.
196      const cw = s / r.h
197      let y = r.y
198      rowAreas.forEach((a, k) => {
199        const h = a / cw
200        out.push({ x: r.x, y, w: cw, h, item: list[from + k]!.item })
201        y += h
202      })
203      r = { x: r.x + cw, y: r.y, w: r.w - cw, h: r.h }
204    } else {
205      // A strip along the top.
206      const rh = s / r.w
207      let x = r.x
208      rowAreas.forEach((a, k) => {
209        const w = a / rh
210        out.push({ x, y: r.y, w, h: rh, item: list[from + k]!.item })
211        x += w
212      })
213      r = { x: r.x, y: r.y + rh, w: r.w, h: r.h - rh }
214    }
215  }
216
217  for (let i = 0; i < areas.length; i++) {
218    const a = areas[i]!
219    const side = Math.min(r.w, r.h)
220    if (row.length === 0 || worst([...row, a], side) <= worst(row, side)) {
221      row.push(a)
222    } else {
223      layRow(rowStart, row)
224      rowStart = i
225      row = [a]
226    }
227  }
228  // The list is not empty, so the last row holds at least its last item.
229  layRow(rowStart, row)
230  return out
231}
232
types/index.d.ts 23 lines
1export type ThermalMetric = 'heat' | 'reads' | 'edits'
2
3export type ThermalFile = {
4  /** Tree path: segments relative to the session cwd, joined by '/'; outside it, under '↗ outside'. */
5  path: string
6  reads: number
7  edits: number
8  writes: number
9  searches: number
10  /** Heat as of `heatAt`; it halves every HALF_LIFE_MS after that. */
11  heat: number
12  heatAt: number
13  lastAt: number
14}
15
16export type ThermalSnap = { cwd: string; files: ThermalFile[]; now: number }
17
18declare module 'claude-code' {
19  interface PluginState {
20    thermal: { snap: ThermalSnap | null; metric: ThermalMetric }
21  }
22}
23