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…

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).
■■■□□, the counts (r read, s search, e edit, w write) and the age of the last touch; as many rows as the pane holds.m): cycles heat → reads → edits.Settings: autoOpen (default false) opens the pane when a session starts.
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).
■■■□□, sayılar (r okuma, s arama, e düzenleme, w yazma) ve son dokunuşun yaşı; panelin sığdırdığı kadar satır.m): ısı → okuma → düzenleme arasında döner.Ayar: autoOpen (varsayılan false) oturum başlarken paneli açar.
hooks/register.tsx 325 lines1import { 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}
325hooks/lib/kz.ts 510 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
510hooks/heatmap.ts 232 lines1// 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}
232types/index.d.ts 23 lines1export 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