KOZMOS Verdict: test, build and lint results above the prompt. Spots the test runners, type checkers, linters and builds Claude runs (vitest, jest, pytest, go…

Test, build and lint results, above the prompt. Verdict watches the Bash and PowerShell commands Claude runs, recognizes test runners, type checkers, linters and builds, parses their output and keeps a short history per runner for each project.
✓ tsc 0 errors ▇▇█ · ✗ vitest 3 failed ▆▇▇█▅ · ✓ eslint 0 errors
npm|pnpm|yarn|bun test, tsc / vue-tsc / typecheck scripts, eslint / lint scripts, npm run build, vite / next / webpack / tsup / esbuild builds, go build / vet. Words inside paths (cat vitest.config.ts) do not count.FAIL … > …, jest ●, pytest FAILED, go --- FAIL, cargo ... FAILED, dotnet Failed …, mocha 1) …, bun (fail), gradle > … FAILED) and the first error lines (tsc file:line TSxxxx, eslint file:line message (rule), rustc with its --> location, maven [ERROR]). An unknown failing build still shows its first error-like lines./verdict opens a pane: each runner's last result and sparkline, then the 25 most recent runs with their command, time, duration and the failing test names / first error lines. /verdict hide|show toggles the band (also ✕), /verdict clear forgets this project's history.Test, derleme ve lint sonuçları istemin üstünde. Verdict, Claude'un çalıştırdığı Bash ve PowerShell komutlarını izler; test koşucularını, tip denetleyicileri, linter'ları ve derlemeleri tanır, çıktılarını ayrıştırır ve her proje için koşucu başına kısa bir geçmiş tutar.
npm test, tsc, eslint, npm run build, vite/next/webpack…/verdict son koşuları ve başarısız test adlarını listeleyen bir panel açar. /verdict hide|show bandı gizler/gösterir (✕ de gizler), /verdict clear bu projenin geçmişini siler.hooks/register.tsx 225 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { VerdictRun, VerdictSnap } from '../types'
5import { KZ, clip, hash, pxOf } from './lib/kz.ts'
6import { detect, judge } from './parse.ts'
7import { bandAlt, bandSvg, chipsOf, clock, duration, failureLines, paneCard, runSummary } from './view.ts'
8
9const PANE = 'kz-verdict'
10const TITLE = 'KOZMOS · Verdict'
11const KEEP = 60
12
13const snapAtom = atom({ plugin: 'verdict', key: 'snap' } as const, null)
14const hiddenAtom = atom({ plugin: 'verdict', key: 'isHidden' } as const, false)
15
16// Live history; the band and pane draw the snapshot in $.state.
17let runs: VerdictRun[] = []
18let sessionStartedAt = 0
19let storeKey = 'runs'
20
21async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
22 await update($, hiddenAtom, () => isHidden)
23 try {
24 await $.store.set('hidden', isHidden)
25 } catch {
26 // Not persisted; this session still honours it.
27 }
28}
29
30async function publish($: EngineInterface): Promise<void> {
31 const snap: VerdictSnap = { runs: [...runs], sessionStartedAt }
32 await update($, snapAtom, () => snap)
33}
34
35async function persist($: EngineInterface): Promise<void> {
36 try {
37 await $.store.set(storeKey, runs)
38 } catch {
39 // History stays in memory for this session.
40 }
41}
42
43async function togglePane($: EngineInterface): Promise<boolean> {
44 if ((await $.ui.panes()).some(p => p.id === PANE)) {
45 await $.ui.close({ id: PANE })
46 return false
47 }
48 await $.ui.open({ id: PANE, title: TITLE })
49 return true
50}
51
52function isRun(x: unknown): x is VerdictRun {
53 const r = x as VerdictRun
54 return typeof r === 'object' && r !== null && typeof r.runner === 'string' && typeof r.at === 'number' && Array.isArray(r.failures)
55}
56
57/** The shell output of a Bash or PowerShell result: stdout and stderr, else the text the model read. */
58function outputOf(result: unknown, text: string | undefined): string {
59 const r = (result ?? {}) as Record<string, unknown>
60 const parts = [r.stdout, r.stderr].filter((s): s is string => typeof s === 'string' && s.length > 0)
61 if (parts.length) return parts.join('\n')
62 if (typeof result === 'string') return result
63 return text ?? ''
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 const started = await next(e)
69 sessionStartedAt = await $.clock.now()
70 storeKey = `runs-${hash(e.cwd || 'default').toString(36)}`
71 runs = []
72 await $.command.register({ name: 'verdict', description: 'KOZMOS: open the Verdict pane of test/build/lint runs (hide, show, clear)', argumentHint: '[hide|show|clear]', immediate: true })
73 try {
74 const stored = await $.store.get(storeKey)
75 if (Array.isArray(stored)) runs = stored.filter(isRun).slice(-KEEP)
76 const hidden = await $.store.get('hidden')
77 if (typeof hidden === 'boolean') await update($, hiddenAtom, () => hidden)
78 } catch {
79 // A fresh store.
80 }
81 await publish($).catch(() => undefined)
82 return started
83 })
84
85 on('command.run', { command: 'verdict' }, async ($, e) => {
86 const arg = e.args.trim().toLowerCase()
87 if (arg === 'hide' || arg === 'show') {
88 await setHidden($, arg === 'hide')
89 return { text: arg === 'hide' ? 'Verdict band hidden.' : 'Verdict band shown.' }
90 }
91 if (arg === 'clear') {
92 await clearRuns($)
93 return { text: 'Verdict history cleared for this project.' }
94 }
95 if (await read($, hiddenAtom)) await setHidden($, false)
96 const isOpen = await togglePane($)
97 const last = runs[runs.length - 1]
98 return { text: isOpen ? `Verdict open.${last ? ` Last: ${last.runner} ${runSummary(last)}.` : ' No runs yet.'}` : 'Verdict closed.' }
99 })
100
101 on('tool.call', async ($, e, next) => {
102 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
103 const command = e.command
104 const background = (e as { run_in_background?: unknown }).run_in_background === true
105 if (background || !detect(command)) return next(e)
106 const startedAt = await $.clock.now()
107 const ran = await next(e)
108 if (ran.deny !== undefined) return ran
109 const result = ran.result as { backgroundTaskId?: string; interrupted?: boolean } | undefined
110 if (result?.backgroundTaskId || result?.interrupted) return ran
111 // judge knows the command: detect() above already did.
112 const o = judge(command, outputOf(ran.result, ran.text), ran.isError === true)!
113 const at = await $.clock.now()
114 runs = [...runs, { ...o, id: (runs[runs.length - 1]?.id ?? 0) + 1, at, ms: at - startedAt, command: clip(command, 120) }].slice(-KEEP)
115 await publish($).catch(() => undefined)
116 await persist($)
117 return ran
118 }).catch(($, e, next) => next(e))
119
120 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121 const drawn = await next(e)
122 if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
123 const snap = await read($, snapAtom)
124 if (!snap) return drawn
125 const chips = chipsOf(snap.runs, snap.sessionStartedAt)
126 if (!chips.length) return drawn
127
128 const ui = $.ui.resolve(e)
129 const { Box, Button } = ui
130 const hide = <Button key="verdict-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
131 const failures = failureLines(chips)
132 const rows = failures.length && e.props.maxRows >= 8 ? 2 : 1
133
134 if ('Svg' in ui && e.surface !== 'terminal') {
135 const { Svg } = ui
136 const W = pxOf(e.props.bodyColumns) - 28
137 const { source, height } = bandSvg(chips, W, rows >= 2)
138 return (
139 <Box flexDirection="column">
140 {drawn}
141 <Box flexDirection="row" alignItems="flex-start">
142 <Svg source={source} alt={bandAlt(chips)} width={W} height={height} />
143 {hide}
144 </Box>
145 </Box>
146 )
147 }
148
149 // Every surface but the terminal has Svg and drew above.
150 const { Client } = ui as Elements['terminal']
151 const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
152 return (
153 <Box flexDirection="column">
154 {drawn}
155 <Box flexDirection="row">
156 <Client key="verdict" module="./bench.tsx" width={cols} height={rows} props={{ cols, rows, chips, failures }} />
157 {hide}
158 </Box>
159 </Box>
160 )
161 })
162
163 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164 const snap = await read($, snapAtom)
165 const all = snap?.runs ?? []
166 const chips = chipsOf(all, 0)
167 const ui = $.ui.resolve(e)
168 const { Box, Text, Button } = ui
169 const recent = [...all].reverse().slice(0, 25)
170 const list = recent.map(r => (
171 <Box key={`run-${r.id}`} flexDirection="column" marginTop={1}>
172 <Text wrap="truncate-end">
173 <Text color={r.ok ? KZ.green : KZ.red} bold>{r.ok ? '✓' : '✗'} {r.runner}</Text>
174 <Text> {runSummary(r)}</Text>
175 <Text dimColor> · {clock(r.at)} · {duration(r.ms)}</Text>
176 </Text>
177 <Text dimColor wrap="truncate-end"> $ {r.command}</Text>
178 {r.failures.map((f, i) => (
179 <Text key={`f${i}`} color={r.ok ? KZ.amber : KZ.red} wrap="truncate-end">
180 {' '}{r.kind === 'test' && r.fail ? '✗' : '!'} {f}
181 </Text>
182 ))}
183 </Box>
184 ))
185 const clear = all.length ? <Button key="verdict-clear" label="Clear history" plain onPress={() => void clearRuns($)} /> : null
186
187 if ('Svg' in ui && e.surface !== 'terminal') {
188 const { Svg } = ui
189 const W = pxOf(e.props.bodyColumns, 48)
190 const { source, height } = paneCard(chips, W)
191 return (
192 <Box flexDirection="column">
193 <Svg source={source} alt={chips.map(c => c.text).join(', ') || 'No runs yet'} width={W} height={height} />
194 {list}
195 {clear}
196 </Box>
197 )
198 }
199
200 return (
201 <Box flexDirection="column">
202 <Text bold color={KZ.violet}>⚖ Verdict <Text dimColor>· {all.length} runs</Text></Text>
203 {chips.length === 0 && <Text dimColor>No test, type-check, lint or build runs yet.</Text>}
204 {chips.map(c => (
205 <Text key={`sum-${c.runner}`} wrap="truncate-end">
206 <Text color={c.ok ? KZ.green : KZ.red}>{c.text}</Text>
207 <Text> </Text>
208 {c.spark.map((p, i) => (
209 <Text key={`p${i}`} color={p.ok ? KZ.green : KZ.red}>{'▁▂▃▄▅▆▇█'[Math.max(0, Math.min(7, Math.round(p.h * 7)))]}</Text>
210 ))}
211 </Text>
212 ))}
213 {list}
214 {clear}
215 </Box>
216 )
217 })
218}
219
220async function clearRuns($: EngineInterface): Promise<void> {
221 runs = []
222 await persist($)
223 await publish($)
224}
225hooks/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/parse.ts 429 lines1// Verdict's parsers: which shell commands run tests, type checks, linters or
2// builds, and what their output says (passed / failed / skipped, error
3// counts, the failing tests' names and the first error lines). Pure.
4
5export type Kind = 'test' | 'types' | 'lint' | 'build'
6
7export type Outcome = {
8 runner: string
9 kind: Kind
10 ok: boolean
11 pass: number
12 fail: number
13 skip: number
14 errors: number
15 warnings: number
16 /** Failing test names or first error lines, at most MAX_FAILURES, each clipped. */
17 failures: string[]
18 /** False when no summary line was recognized (the verdict is the exit status alone). */
19 parsed: boolean
20}
21
22export const MAX_FAILURES = 8
23const MAX_LINE = 160
24
25/** Removes ANSI escapes and carriage-return overprints. */
26export function clean(text: string): string {
27 return text
28 .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
29 .replace(/\u001b\][^\u0007]*\u0007/g, '')
30 .split('\n')
31 .map(l => l.slice(l.lastIndexOf('\r') + 1))
32 .join('\n')
33}
34
35const clip = (s: string): string => {
36 const one = s.replace(/\s+/g, ' ').trim()
37 return one.length > MAX_LINE ? one.slice(0, MAX_LINE - 1) + '…' : one
38}
39
40function uniq(xs: string[]): string[] {
41 const out: string[] = []
42 for (const x of xs.map(clip)) if (x && !out.includes(x)) out.push(x)
43 return out.slice(0, MAX_FAILURES)
44}
45
46const num = (re: RegExp, s: string): number => Number(re.exec(s)?.[1] ?? 0) || 0
47
48/** Every match of `re`: the patterns here come without the g flag and never match an empty string. */
49function all(re: RegExp, s: string): RegExpExecArray[] {
50 const g = new RegExp(re.source, re.flags + 'g')
51 const out: RegExpExecArray[] = []
52 let m: RegExpExecArray | null
53 while ((m = g.exec(s)) !== null) out.push(m)
54 return out
55}
56
57// ---------------------------------------------------------------------------
58// Detection.
59
60type Rule = { re: RegExp; runner: string; kind: Kind }
61
62/** A word that is a command here, not part of a path or file name. */
63const w = (word: string): string => `(?<![\\w./\\\\-])(?:\\.[\\\\/])?${word}(?![\\w.-])`
64const PM = '(?:npm|pnpm|yarn|bun)'
65
66const RULES: Rule[] = [
67 { re: new RegExp(w('vitest')), runner: 'vitest', kind: 'test' },
68 { re: new RegExp(w('jest')), runner: 'jest', kind: 'test' },
69 { re: new RegExp(`${w('pytest')}|${w('py\\.test')}|python[\\d.]*(?:\\.exe)? -m pytest`), runner: 'pytest', kind: 'test' },
70 { re: new RegExp(`${w('go')} test(?![\\w.-])`), runner: 'go test', kind: 'test' },
71 { re: new RegExp(`${w('cargo')} (?:test|nextest)(?![\\w.-])`), runner: 'cargo test', kind: 'test' },
72 { re: new RegExp(`${w('dotnet')} test(?![\\w.-])`), runner: 'dotnet test', kind: 'test' },
73 { re: new RegExp(w('mocha')), runner: 'mocha', kind: 'test' },
74 { re: new RegExp(`${w('bun')} test(?![\\w.-])`), runner: 'bun test', kind: 'test' },
75 { re: new RegExp(`${w('mvnw?')}(?: [^|&;]*)? (?:test|verify)(?![\\w.-])`), runner: 'maven', kind: 'test' },
76 { re: new RegExp(`${w('gradlew?')}(?: [^|&;]*)? (?::?\\w+:)?test(?![\\w.-])`), runner: 'gradle', kind: 'test' },
77 { re: new RegExp(`${w(PM)} (?:run )?test(?![\\w.-])|${w('npx?')} test(?![\\w.-])|${w(PM)} t(?![\\w.-])`), runner: 'test', kind: 'test' },
78 { re: new RegExp(`${w('tsc')}|${w('vue-tsc')}|${w(PM)} (?:run )?(?:typecheck|type-check|tsc)(?![\\w.-])`), runner: 'tsc', kind: 'types' },
79 { re: new RegExp(`${w('eslint')}|${w(PM)} (?:run )?lint(?![\\w.-])`), runner: 'eslint', kind: 'lint' },
80 { re: new RegExp(`${w('cargo')} (?:build|check|clippy)(?![\\w.-])`), runner: 'cargo build', kind: 'build' },
81 { re: new RegExp(`${w('go')} (?:build|vet)(?![\\w.-])`), runner: 'go build', kind: 'build' },
82 { re: new RegExp(`${w('dotnet')} build(?![\\w.-])`), runner: 'dotnet build', kind: 'build' },
83 { re: new RegExp(w('mvnw?')), runner: 'maven', kind: 'build' },
84 { re: new RegExp(w('gradlew?')), runner: 'gradle', kind: 'build' },
85 { re: new RegExp(`${w(PM)} (?:run )?build(?![\\w.-])|${w('vite')} build|${w('next')} build|${w('webpack')}|${w('tsup')}|${w('esbuild')}`), runner: 'build', kind: 'build' },
86]
87
88/** The runner a shell command invokes, or undefined when it runs none we know. */
89export function detect(command: string): { runner: string; kind: Kind } | undefined {
90 const cmd = command.replace(/\s+/g, ' ')
91 for (const r of RULES) if (r.re.test(cmd)) return { runner: r.runner, kind: r.kind }
92 return undefined
93}
94
95// ---------------------------------------------------------------------------
96// Parsers, one per family. Each returns undefined when its summary is absent.
97
98type Part = Omit<Outcome, 'runner' | 'kind' | 'ok' | 'parsed'> & { runner?: string; buildFailed?: boolean }
99
100const zero = (): Part => ({ pass: 0, fail: 0, skip: 0, errors: 0, warnings: 0, failures: [] })
101
102export function parseVitest(out: string): Part | undefined {
103 const tests = /^\s*Tests\s+(.+?)\s*\((\d+)\)\s*$/m.exec(out)
104 if (!tests) return undefined
105 const s = tests[1]!
106 const p = zero()
107 p.fail = num(/(\d+) failed/, s)
108 p.pass = num(/(\d+) passed/, s)
109 p.skip = num(/(\d+) skipped/, s) + num(/(\d+) todo/, s)
110 const errs = num(/^\s*Errors\s+(\d+) errors?/m, out)
111 p.errors = errs
112 const fails = all(/^\s*FAIL\s+(.+?)\s*$/m, out).map(m => m[1]!)
113 const crosses = all(/^\s*[×✗]\s+(.+?)(?:\s+\d+m?s)?\s*$/m, out).map(m => m[1]!)
114 p.failures = uniq(fails.length ? fails : crosses)
115 p.runner = 'vitest'
116 return p
117}
118
119export function parseJest(out: string): Part | undefined {
120 const tests = /^Tests:\s+(.*?)(\d+) total/m.exec(out)
121 if (!tests) return undefined
122 const s = tests[1]!
123 const p = zero()
124 p.fail = num(/(\d+) failed/, s)
125 p.pass = num(/(\d+) passed/, s)
126 p.skip = num(/(\d+) skipped/, s) + num(/(\d+) todo/, s)
127 p.failures = uniq(all(/^\s*● (?!Console)(.+?)\s*$/m, out).map(m => m[1]!).filter(x => !/^Test suite failed to run/.test(x)))
128 if (!p.failures.length) p.failures = uniq(all(/^\s*✕ (.+?)(?: \(\d+ ?m?s\))?\s*$/m, out).map(m => m[1]!))
129 p.runner = 'jest'
130 return p
131}
132
133export function parsePytest(out: string): Part | undefined {
134 const lines = all(/^(?:=+ )?((?:\d+ (?:passed|failed|skipped|errors?|xfailed|xpassed|deselected|warnings?)(?:, )?)+) in [\d.]+s(?: \([\d:]+\))?(?: =+)?\s*$/m, out)
135 const last = lines[lines.length - 1]
136 if (!last) {
137 if (/^=+ no tests ran/m.test(out)) return zero()
138 return undefined
139 }
140 const s = last[1]!
141 const p = zero()
142 p.pass = num(/(\d+) passed/, s) + num(/(\d+) xpassed/, s)
143 p.fail = num(/(\d+) failed/, s)
144 p.skip = num(/(\d+) skipped/, s) + num(/(\d+) xfailed/, s) + num(/(\d+) deselected/, s)
145 p.errors = num(/(\d+) errors?/, s)
146 p.warnings = num(/(\d+) warnings?/, s)
147 p.failures = uniq([
148 ...all(/^FAILED (\S+)(?: - (.*))?$/m, out).map(m => (m[2] ? `${m[1]} — ${m[2]}` : m[1]!)),
149 ...all(/^ERROR (\S+)(?: - (.*))?$/m, out).map(m => (m[2] ? `${m[1]} — ${m[2]}` : m[1]!)),
150 ])
151 p.runner = 'pytest'
152 return p
153}
154
155export function parseGo(out: string): Part | undefined {
156 const pass = all(/^\s*--- PASS: /m, out).length
157 const failNames = all(/^\s*--- FAIL: (\S+)/m, out).map(m => m[1]!)
158 const skip = all(/^\s*--- SKIP: /m, out).length
159 const okPkgs = all(/^ok\s+\S+/m, out).length
160 const failPkgs = all(/^FAIL\s+(\S+)(?:\s+\[build failed\]|\s+[\d.]+s)?\s*$/m, out).map(m => m[1]!)
161 const compile = all(/^(\S+\.go:\d+:\d+: .+)$/m, out).map(m => m[1]!)
162 if (!pass && !failNames.length && !skip && !okPkgs && !failPkgs.length && !compile.length) return undefined
163 const p = zero()
164 if (pass || failNames.length || skip) {
165 p.pass = pass
166 p.fail = failNames.length
167 p.skip = skip
168 } else {
169 p.pass = okPkgs
170 p.fail = failPkgs.length
171 }
172 p.errors = compile.length
173 p.failures = uniq([...failNames, ...compile, ...(failNames.length ? [] : failPkgs.map(x => `package ${x}`))])
174 p.runner = 'go test'
175 return p
176}
177
178/** rustc diagnostics: `error[E0308]: mismatched types` with its `--> src/x.rs:3:5` line. */
179function rustErrors(out: string): { errors: string[]; warnings: number } {
180 const errors: string[] = []
181 const lines = out.split('\n')
182 lines.forEach((l, i) => {
183 const m = /^error(\[E\d+\])?: (.+)$/.exec(l)
184 if (!m || /^(could not compile|aborting due to|test failed|build failed|\d+ previous errors?)/.test(m[2]!)) return
185 const at = /^\s*--> (.+)$/.exec(lines[i + 1] ?? '')?.[1]
186 errors.push(at ? `${at} ${m[1] ?? ''} ${m[2]}` : `${m[1] ?? ''} ${m[2]}`)
187 })
188 const warnings = all(/^warning: (?!.*generated \d+ warnings?)(?!unused manifest)/m, out).length
189 return { errors, warnings }
190}
191
192export function parseCargo(out: string): Part | undefined {
193 const results = all(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored/m, out)
194 const rust = rustErrors(out)
195 if (!results.length && !rust.errors.length && !/Finished|Compiling/.test(out)) return undefined
196 const p = zero()
197 for (const r of results) {
198 p.pass += Number(r[1])
199 p.fail += Number(r[2])
200 p.skip += Number(r[3])
201 }
202 p.errors = rust.errors.length
203 p.warnings = rust.warnings
204 p.failures = uniq([...all(/^test (\S+) \.\.\. FAILED$/m, out).map(m => m[1]!), ...rust.errors])
205 return p
206}
207
208export function parseDotnet(out: string): Part | undefined {
209 const p = zero()
210 const modern = all(/(?:Failed|Passed)!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+)/m, out)
211 const builds = /(\d+) Error\(s\)/.exec(out)
212 if (modern.length) {
213 for (const m of modern) {
214 p.fail += Number(m[1])
215 p.pass += Number(m[2])
216 p.skip += Number(m[3])
217 }
218 } else if (/^\s*Total tests: \d+/m.test(out)) {
219 p.pass = num(/^\s*Passed: (\d+)/m, out)
220 p.fail = num(/^\s*Failed: (\d+)/m, out)
221 p.skip = num(/^\s*Skipped: (\d+)/m, out)
222 } else if (!builds && !/: error [A-Z]+\d+:/.test(out)) {
223 return undefined
224 }
225 const errLines = all(/^\s*(\S.*?: error [A-Z]+\d+: .+?)(?: \[[^\]]+\])?\s*$/m, out).map(m => m[1]!)
226 p.errors = builds ? Number(builds[1]) : uniq(errLines).length
227 p.warnings = num(/(\d+) Warning\(s\)/, out)
228 p.failures = uniq([...all(/^\s*Failed (\S+) \[/m, out).map(m => m[1]!), ...errLines])
229 return p
230}
231
232export function parseMocha(out: string): Part | undefined {
233 if (!/^\s*\d+ passing/m.test(out) && !/^\s*\d+ failing/m.test(out)) return undefined
234 const p = zero()
235 p.pass = num(/^\s*(\d+) passing/m, out)
236 p.fail = num(/^\s*(\d+) failing/m, out)
237 p.skip = num(/^\s*(\d+) pending/m, out)
238 const at = out.search(/^\s*\d+ failing/m)
239 const tail = at >= 0 ? out.slice(at).split('\n').slice(1) : []
240 const names: string[] = []
241 for (let i = 0; i < tail.length; i++) {
242 const m = /^\s+\d+\) (.+)$/.exec(tail[i]!)
243 if (!m) continue
244 const path = [m[1]!]
245 // The title continues on deeper-indented lines until one ends with a colon.
246 while (!/:$/.test(path[path.length - 1]!) && i + 1 < tail.length && /^\s{4,}\S/.test(tail[i + 1]!) && path.length < 6) path.push(tail[++i]!.trim())
247 names.push(path.join(' › ').replace(/:$/, ''))
248 }
249 p.failures = uniq(names)
250 p.runner = 'mocha'
251 return p
252}
253
254export function parseBun(out: string): Part | undefined {
255 if (!/^\s*\d+ pass\s*$/m.test(out) && !/^\s*\d+ fail\s*$/m.test(out)) return undefined
256 const p = zero()
257 p.pass = num(/^\s*(\d+) pass\s*$/m, out)
258 p.fail = num(/^\s*(\d+) fail\s*$/m, out)
259 p.skip = num(/^\s*(\d+) skip\s*$/m, out) + num(/^\s*(\d+) todo\s*$/m, out)
260 p.failures = uniq(all(/^\(fail\) (.+?)(?: \[[\d.]+m?s\])?\s*$/m, out).map(m => m[1]!))
261 p.runner = 'bun test'
262 return p
263}
264
265export function parseTsc(out: string): Part | undefined {
266 const lines = [
267 ...all(/^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/m, out),
268 ...all(/^(.+?):(\d+):(\d+) - error (TS\d+): (.+)$/m, out),
269 ].map(m => `${m[1]}:${m[2]} ${m[4]} ${m[5]}`)
270 const found = /Found (\d+) errors?/.exec(out)
271 if (!found && !lines.length) return undefined
272 const p = zero()
273 p.errors = found ? Number(found[1]) : lines.length
274 p.failures = uniq(lines)
275 return p
276}
277
278export function parseEslint(out: string): Part | undefined {
279 const summary = /✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)/.exec(out)
280 const p = zero()
281 let file = ''
282 const errs: string[] = []
283 for (const l of out.split('\n')) {
284 if (/^(?:[A-Za-z]:)?[/\\]?\S.*\.\w+$/.test(l) && !/^\s/.test(l)) file = l.trim()
285 const m = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}(\S+))?\s*$/.exec(l)
286 if (m && m[3] === 'error') errs.push(`${file ? file.split(/[\\/]/).pop() + ':' : ''}${m[1]} ${m[4]}${m[5] ? ` (${m[5]})` : ''}`)
287 }
288 if (!summary && !errs.length) return undefined
289 p.errors = summary ? Number(summary[2]) : errs.length
290 p.warnings = summary ? Number(summary[3]) : 0
291 p.failures = uniq(errs)
292 return p
293}
294
295export function parseMaven(out: string): Part | undefined {
296 const runs = all(/Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)/m, out)
297 const verdict = /BUILD (SUCCESS|FAILURE)/.exec(out)
298 if (!runs.length && !verdict) return undefined
299 const p = zero()
300 const last = runs[runs.length - 1]
301 if (last) {
302 const total = Number(last[1])
303 p.fail = Number(last[2]) + Number(last[3])
304 p.skip = Number(last[4])
305 p.pass = Math.max(0, total - p.fail - p.skip)
306 }
307 const errLines = all(/^\[ERROR\] (.+)$/m, out).map(m => m[1]!).filter(x => x.trim() && !/^(->|Re-run|To see the full|For more information|\[Help|Failed to execute goal|Tests run:|Failures:|Errors:|Run \d|$)/.test(x.trim()))
308 p.failures = uniq(errLines)
309 p.errors = verdict?.[1] === 'FAILURE' && p.fail === 0 ? Math.max(1, all(/^\[ERROR\] .+\.(?:java|kt):\[?\d+/m, out).length) : 0
310 p.buildFailed = verdict?.[1] === 'FAILURE'
311 return p
312}
313
314export function parseGradle(out: string): Part | undefined {
315 const verdict = /BUILD (SUCCESSFUL|FAILED)/.exec(out)
316 const tests = /(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/.exec(out)
317 if (!verdict && !tests) return undefined
318 const p = zero()
319 if (tests) {
320 const total = Number(tests[1])
321 p.fail = Number(tests[2]) || 0
322 p.skip = Number(tests[3]) || 0
323 p.pass = Math.max(0, total - p.fail - p.skip)
324 }
325 const compile = [...all(/^e: (.+)$/m, out).map(m => m[1]!), ...all(/^(.+\.java:\d+: error: .+)$/m, out).map(m => m[1]!)]
326 const wrong = /\* What went wrong:\n(.+)/.exec(out)?.[1]
327 p.errors = compile.length || (verdict?.[1] === 'FAILED' && p.fail === 0 ? 1 : 0)
328 p.failures = uniq([...all(/^(\S+ > .+) FAILED$/m, out).map(m => m[1]!), ...compile, ...(wrong ? [wrong] : [])])
329 p.buildFailed = verdict?.[1] === 'FAILED'
330 return p
331}
332
333/** Error-looking lines of an unknown build tool, for a failed build. */
334function genericErrors(out: string): string[] {
335 return uniq(out.split('\n').filter(l => /\berror\b|\bERR!|✘|\bfailed\b/i.test(l) && !/^\s*(?:npm )?ERR! (?:A complete log|code|path|errno)/.test(l)))
336}
337
338// ---------------------------------------------------------------------------
339
340const BY_RUNNER: Record<string, ((out: string) => Part | undefined)[]> = {
341 vitest: [parseVitest],
342 jest: [parseJest],
343 pytest: [parsePytest],
344 'go test': [parseGo],
345 'go build': [parseGo],
346 'cargo test': [parseCargo],
347 'cargo build': [parseCargo],
348 'dotnet test': [parseDotnet],
349 'dotnet build': [parseDotnet],
350 mocha: [parseMocha],
351 'bun test': [parseBun],
352 maven: [parseMaven],
353 gradle: [parseGradle],
354 test: [parseVitest, parseJest, parseBun, parseMocha, parsePytest, parseGo, parseCargo, parseDotnet],
355 tsc: [parseTsc],
356 eslint: [parseEslint],
357 build: [parseTsc, parseEslint],
358}
359
360/**
361 * The verdict of one run: `command` as given to the shell, `output` its
362 * stdout and stderr, `isError` whether the tool reported a failure (a
363 * non-zero exit). Undefined when the command runs nothing Verdict knows.
364 */
365export function judge(command: string, output: string, isError: boolean): Outcome | undefined {
366 const hit = detect(command)
367 if (!hit) return undefined
368 const out = clean(output)
369 let part: Part | undefined
370 // Every rule's runner has its parsers.
371 for (const parse of BY_RUNNER[hit.runner]!) {
372 part = parse(out)
373 if (part) break
374 }
375 const runner = hit.runner === 'test' || hit.runner === 'build' ? part?.runner ?? (hit.runner === 'test' ? detectByOutput(out) : 'build') : hit.runner
376 const p = part ?? zero()
377 if (!part && isError) {
378 p.failures = genericErrors(out)
379 if (hit.kind !== 'test') p.errors = Math.max(1, Math.min(p.failures.length, 99))
380 }
381 if (part && hit.kind === 'build' && isError && p.errors === 0 && p.fail === 0) {
382 p.errors = 1
383 if (!p.failures.length) p.failures = genericErrors(out)
384 }
385 const ok = hit.kind === 'test'
386 ? !isError && p.fail === 0 && p.errors === 0
387 : !isError && p.errors === 0 && p.buildFailed !== true
388 return {
389 runner,
390 kind: hit.kind,
391 ok,
392 pass: p.pass,
393 fail: p.fail,
394 skip: p.skip,
395 errors: p.errors,
396 warnings: p.warnings,
397 failures: p.failures,
398 parsed: part !== undefined,
399 }
400}
401
402function detectByOutput(out: string): string {
403 if (/\bvitest\b/i.test(out)) return 'vitest'
404 if (/\bjest\b/i.test(out)) return 'jest'
405 return 'test'
406}
407
408/** `✓ vitest 142`, `✗ vitest 3 failed`, `✓ tsc 0 errors`, `✗ eslint 4 errors`, `✓ build`. */
409export function chipText(o: Pick<Outcome, 'runner' | 'kind' | 'ok' | 'pass' | 'fail' | 'errors' | 'warnings' | 'parsed'>): string {
410 const mark = o.ok ? '✓' : '✗'
411 if (o.kind === 'test') {
412 if (o.fail > 0) return `${mark} ${o.runner} ${o.fail} failed`
413 if (!o.ok) return o.errors > 0 ? `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}` : `${mark} ${o.runner} failed`
414 return `${mark} ${o.runner} ${o.parsed ? o.pass : 'passed'}`
415 }
416 if (o.kind === 'build') {
417 if (o.ok) return `${mark} ${o.runner}${o.warnings ? ` · ${o.warnings} warn` : ''}`
418 return o.errors > 1 || o.parsed ? `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}` : `${mark} ${o.runner} failed`
419 }
420 return `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}${o.warnings ? ` · ${o.warnings} warn` : ''}`
421}
422
423/** 0..1 for a sparkline: the share of tests passing, or 1 / (1 + errors). */
424export function health(o: Pick<Outcome, 'kind' | 'ok' | 'pass' | 'fail' | 'errors'>): number {
425 if (o.kind === 'test' && o.pass + o.fail > 0) return o.pass / (o.pass + o.fail)
426 if (o.errors > 0) return 1 / (1 + o.errors)
427 return o.ok ? 1 : 0.1
428}
429hooks/view.ts 158 lines1// Verdict's views: the chips per runner, the desktop SVG band and the pane's
2// header card. Pure: no `$` here.
3import type { VerdictRun } from '../types'
4import { KZ, fitText, svg, svgText, textWidth } from './lib/kz.ts'
5import { chipText, health } from './parse.ts'
6
7export type Chip = {
8 runner: string
9 text: string
10 ok: boolean
11 /** The runner's last runs, oldest first: health 0..1 and whether each passed. */
12 spark: { h: number; ok: boolean }[]
13 failures: string[]
14 at: number
15}
16
17export const SPARK_RUNS = 10
18
19/** One chip per runner seen since `since`, the most recent first; sparklines span all history. */
20export function chipsOf(runs: readonly VerdictRun[], since: number): Chip[] {
21 const byRunner = new Map<string, VerdictRun[]>()
22 for (const r of runs) {
23 const list = byRunner.get(r.runner) ?? []
24 list.push(r)
25 byRunner.set(r.runner, list)
26 }
27 const chips: Chip[] = []
28 for (const [runner, list] of byRunner) {
29 const last = list[list.length - 1]! // a runner is listed with its first run
30 if (last.at < since) continue
31 chips.push({
32 runner,
33 text: chipText(last),
34 ok: last.ok,
35 spark: list.slice(-SPARK_RUNS).map(r => ({ h: health(r), ok: r.ok })),
36 failures: last.ok ? [] : last.failures,
37 at: last.at,
38 })
39 }
40 return chips.sort((a, b) => b.at - a.at)
41}
42
43const BARS = '▁▂▃▄▅▆▇█'
44export function sparkBars(spark: readonly { h: number; ok: boolean }[]): { s: string; ok: boolean }[] {
45 return spark.map(p => ({ s: BARS[Math.max(0, Math.min(7, Math.round(p.h * 7)))]!, ok: p.ok }))
46}
47
48/** The failures to cycle through on the band: the failing chips', newest first. */
49export function failureLines(chips: readonly Chip[]): string[] {
50 return chips.flatMap(c => c.failures.map(f => `${c.runner}: ${f}`)).slice(0, 12)
51}
52
53// ---------------------------------------------------------------------------
54// Desktop band: pills with sparklines; failing ones pulse red; a second line
55// cycles through the failing tests by CSS.
56
57export function bandSvg(chips: readonly Chip[], W: number, withFailures: boolean): { source: string; height: number } {
58 const fails = withFailures ? failureLines(chips).slice(0, 6) : []
59 const H = fails.length ? 54 : 30
60 const parts: string[] = []
61 const css = `
62.pz{animation:pz 1.4s ease-in-out infinite}@keyframes pz{0%,100%{stroke-opacity:.95;stroke-width:1.6}50%{stroke-opacity:.25;stroke-width:4}}
63.bk{animation:bk 1.4s ease-in-out infinite}@keyframes bk{50%{opacity:.3}}
64.cy{opacity:0;animation:cy ${Math.max(1, fails.length) * 3}s linear infinite}@keyframes cy{0%{opacity:0}${(2 / Math.max(1, fails.length)).toFixed(1)}%{opacity:1}${(100 / Math.max(1, fails.length) - 2).toFixed(1)}%{opacity:1}${(100 / Math.max(1, fails.length)).toFixed(1)}%{opacity:0}100%{opacity:0}}`
65 let x = 0
66 let hidden = 0
67 for (const c of chips) {
68 const tw = textWidth(c.text, 12)
69 const sw = c.spark.length * 4
70 const w = 14 + tw + 8 + sw + 12
71 if (x + w > W) {
72 hidden++
73 continue
74 }
75 const color = c.ok ? KZ.green : KZ.red
76 parts.push(`<rect class="p" x="${x + 1}" y="2" width="${w - 2}" height="26" rx="13"/>`)
77 parts.push(`<rect x="${x + 1}" y="2" width="${w - 2}" height="26" rx="13" fill="none" stroke="${color}" stroke-opacity="${c.ok ? 0.45 : 0.9}" class="${c.ok ? '' : 'pz'}"/>`)
78 parts.push(`<circle cx="${x + 12}" cy="15" r="3.5" fill="${color}" class="${c.ok ? '' : 'bk'}"/>`)
79 parts.push(svgText(x + 20, 19.5, c.text.replace(/^[✓✗] /, ''), { size: 12, weight: 650, fill: c.ok ? undefined : KZ.red, cls: 't' }))
80 let bx = x + 20 + tw - textWidth('✓ ', 12) + 8
81 for (const p of c.spark) {
82 const bh = 3 + p.h * 13
83 parts.push(`<rect x="${bx.toFixed(1)}" y="${(22 - bh).toFixed(1)}" width="3" height="${bh.toFixed(1)}" rx="1" fill="${p.ok ? KZ.green : KZ.red}" opacity="${p.ok ? 0.75 : 0.95}"/>`)
84 bx += 4
85 }
86 x += w + 6
87 }
88 if (hidden > 0) parts.push(svgText(Math.min(x + 4, W - 40), 19.5, `+${hidden}`, { cls: 'm', size: 11 }))
89 fails.forEach((f, i) => {
90 parts.push(`<g class="cy" style="animation-delay:${i * 3}s">${svgText(14, 46, fitText(`↳ ${f}`, 11.5, W - 20), { size: 11.5, fill: '#ef4444', mono: true })}</g>`)
91 })
92 return { source: svg(W, H, parts.join(''), css), height: H }
93}
94
95export function bandAlt(chips: readonly Chip[]): string {
96 return chips.map(c => c.text).join(' · ')
97}
98
99// ---------------------------------------------------------------------------
100// Pane header: one tile per runner with its last ten runs as bars.
101
102export function paneCard(chips: readonly Chip[], W: number): { source: string; height: number } {
103 if (!chips.length) {
104 const body = `<rect class="p" width="${W}" height="56" rx="12"/>${svgText(16, 25, 'No runs yet', { size: 14, weight: 650 })}${svgText(16, 43, 'Tests, type checks, lints and builds Claude runs land here.', { cls: 's', size: 11 })}`
105 return { source: svg(W, 56, body), height: 56 }
106 }
107 const cols = W >= 520 ? 3 : W >= 340 ? 2 : 1
108 const tw = (W - (cols - 1) * 8) / cols
109 const th = 64
110 const rows = Math.ceil(chips.length / cols)
111 const H = rows * (th + 8) - 8
112 const parts: string[] = []
113 const css = `.pz{animation:pz 1.4s ease-in-out infinite}@keyframes pz{50%{stroke-opacity:.2}}`
114 chips.forEach((c, i) => {
115 const x = (i % cols) * (tw + 8)
116 const y = Math.floor(i / cols) * (th + 8)
117 const color = c.ok ? KZ.green : KZ.red
118 parts.push(`<rect class="p" x="${x}" y="${y}" width="${tw}" height="${th}" rx="12"/>`)
119 parts.push(`<rect x="${x + 0.5}" y="${y + 0.5}" width="${tw - 1}" height="${th - 1}" rx="12" fill="none" stroke="${color}" stroke-opacity=".8" class="${c.ok ? '' : 'pz'}"/>`)
120 parts.push(svgText(x + 12, y + 20, c.runner.toUpperCase(), { cls: 's', size: 10, weight: 700 }))
121 parts.push(svgText(x + 12, y + 42, fitText(c.text, 14, tw - 24), { size: 14, weight: 700, fill: color }))
122 const n = c.spark.length
123 c.spark.forEach((p, j) => {
124 const bh = 3 + p.h * 14
125 parts.push(`<rect x="${(x + tw - 12 - (n - j) * 6).toFixed(1)}" y="${(y + 56 - bh).toFixed(1)}" width="4" height="${bh.toFixed(1)}" rx="1.5" fill="${p.ok ? KZ.green : KZ.red}" opacity=".85"/>`)
126 })
127 })
128 return { source: svg(W, H, parts.join(''), css), height: H }
129}
130
131export function clock(at: number): string {
132 const d = new Date(at)
133 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
134}
135
136export function duration(ms: number): string {
137 if (ms < 1000) return `${Math.round(ms)}ms`
138 if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
139 return `${Math.floor(ms / 60_000)}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
140}
141
142/** One run in words, for the pane: `142 passed · 3 failed · 2 skipped`. */
143export function runSummary(r: VerdictRun): string {
144 const bits: string[] = []
145 if (r.kind === 'test') {
146 if (r.pass || r.parsed) bits.push(`${r.pass} passed`)
147 if (r.fail) bits.push(`${r.fail} failed`)
148 if (r.skip) bits.push(`${r.skip} skipped`)
149 if (r.errors) bits.push(`${r.errors} ${r.errors === 1 ? 'error' : 'errors'}`)
150 if (!bits.length) bits.push(r.ok ? 'passed' : 'failed')
151 } else {
152 bits.push(r.ok && !r.errors ? (r.kind === 'build' ? 'built' : '0 errors') : `${r.errors || 1} ${r.errors === 1 || !r.errors ? 'error' : 'errors'}`)
153 if (r.warnings) bits.push(`${r.warnings} ${r.warnings === 1 ? 'warning' : 'warnings'}`)
154 }
155 return bits.join(' · ')
156}
157
158hooks/bench.tsx 88 lines1// Verdict's terminal band: result chips with sparklines; a failing chip's
2// beacon and text pulse red, and a second row types out the failing tests one
3// after another. ~30 fps on the surface; no `$` here.
4import type { ClientModule, RenderElement } from 'claude-code'
5
6import { KZ, mix } from './lib/kz.ts'
7import { sparkBars } from './view.ts'
8import type { Chip } from './view.ts'
9
10export type BenchProps = { cols: number; rows: number; chips: Chip[]; failures: string[] }
11type S = { t: number }
12
13const FRAME = 33
14const DWELL = 75
15
16const Bench: ClientModule<BenchProps, S> = (props, surface) => {
17 if (surface.state === undefined) {
18 surface.setState({ t: 0 })
19 surface.every(FRAME, () => surface.setState({ t: (surface.state?.t ?? 0) + 1 }))
20 }
21 const t = surface.state?.t ?? 0
22 const cols = Math.max(10, surface.columns || props.cols)
23 const pulse = (Math.sin(t / 4.5) + 1) / 2
24 const { Box, Text } = surface.elements
25
26 // Row 1: the chips, as many as fit.
27 const nodes: RenderElement[] = []
28 let used = 0
29 let hidden = 0
30 props.chips.forEach((c, i) => {
31 const bars = sparkBars(c.spark)
32 const len = c.text.length + 1 + bars.length + (i ? 3 : 0)
33 if (used + len > cols - 4) {
34 hidden++
35 return
36 }
37 used += len
38 if (i) nodes.push(<Text key={`sep${i}`} dimColor> · </Text>)
39 const color = c.ok ? KZ.green : mix('#f87171', '#fee2e2', pulse * 0.75)
40 const beacon = c.ok ? '' : pulse > 0.5 ? '◉' : '○'
41 nodes.push(
42 <Text key={`c${i}`} color={color} bold={!c.ok}>
43 {beacon ? c.text.replace(/^✗/, beacon) : c.text}
44 </Text>,
45 )
46 nodes.push(<Text key={`s${i}`}> </Text>)
47 bars.forEach((b, j) => {
48 const isLast = j === bars.length - 1
49 nodes.push(
50 <Text key={`b${i}-${j}`} color={b.ok ? (isLast ? KZ.green : '#16a34a') : isLast ? color : '#dc2626'}>
51 {b.s}
52 </Text>,
53 )
54 })
55 })
56 if (hidden) nodes.push(<Text key="more" dimColor>{` +${hidden}`}</Text>)
57
58 // Row 2: the failing tests, typed out one at a time.
59 let detail: RenderElement | null = null
60 if (props.rows >= 2 && props.failures.length) {
61 const n = props.failures.length
62 const k = Math.floor(t / DWELL) % n
63 const line = props.failures[k]! // k < n
64 const shown = Math.min(line.length, Math.floor(((t % DWELL) + 1) * 3))
65 const head = n > 1 ? `${k + 1}/${n} ` : ''
66 const room = Math.max(4, cols - 4 - head.length)
67 const text = line.slice(0, shown)
68 const clipped = text.length > room ? text.slice(0, room - 1) + '…' : text
69 detail = (
70 <Text key="fail" wrap="truncate-end">
71 <Text color="#ef4444">{' ↳ '}</Text>
72 <Text dimColor>{head}</Text>
73 <Text color="#fca5a5">{clipped}</Text>
74 <Text color="#fca5a5">{shown < line.length && t % 8 < 4 ? '▌' : ''}</Text>
75 </Text>
76 )
77 }
78
79 return (
80 <Box flexDirection="column">
81 <Text key="chips" wrap="truncate-end">{nodes}</Text>
82 {detail}
83 </Box>
84 )
85}
86
87export default Bench
88types/index.d.ts 38 lines1export type VerdictKind = 'test' | 'types' | 'lint' | 'build'
2
3/** One recorded run of a test runner, type checker, linter or build. */
4export type VerdictRun = {
5 id: number
6 /** When it finished (ms). */
7 at: number
8 /** How long it ran (ms). */
9 ms: number
10 runner: string
11 kind: VerdictKind
12 ok: boolean
13 pass: number
14 fail: number
15 skip: number
16 errors: number
17 warnings: number
18 /** Failing test names or first error lines. */
19 failures: string[]
20 /** The shell command, clipped. */
21 command: string
22 /** False when only the exit status was known. */
23 parsed: boolean
24}
25
26export type VerdictSnap = {
27 /** This project's recent runs, oldest first (persisted across sessions). */
28 runs: VerdictRun[]
29 /** Runs at or after this time belong to this session (the band shows them). */
30 sessionStartedAt: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 verdict: { snap: VerdictSnap | null; isHidden: boolean }
36 }
37}
38